Infoteam Blog
소개지원 바로가기

Kubernetes Cluster 확장

이전 글에서 볼 수 있다시피 인포팀에서는 k3s를 사용해서 인프라를 구성하고 있습니다.
기존에는 lightsail 인스턴스 하나만 사용해서 클러스터를 구성하고 있었습니다.
resource "aws_lightsail_instance" "self" { availability_zone = "ap-northeast-2a" blueprint_id = "ubuntu_22_04" bundle_id = "large_3_0" name = var.name key_pair_name = var.key_pair lifecycle { prevent_destroy = true } }
자원의 한계를 느껴서 물리 노드를 사용해 이를 확장하는 작업을 2025년 10월경 진행했습니다.
지금은 인스턴스를 하나 더 확장해서 총 3개의 노드로 이루어진 클러스터를 사용하고 있습니다.
이 글에서는 어떻게 물리 노드들과 lightsail을 하나의 클러스터로 묶는지, 그 방법에 대해 소개합니다.

클러스터 네트워크 묶기

먼저 물리 노드들은 같은 VPC 내부에 있는 것이 아니기 때문에 네트워크로 묶는 것이 편리하다고 판단했습니다. 그러기 위해서 wireguard를 사용하였습니다.
lightsail에 떠 있는 노드는 IP가 고정 되어 있기 때문에 lightsail의 노드를 wireguard 서버로 가정하고, 나머지 노드를 wireguard 클라이언트로 사용하였습니다.

서버 구성

[Interface] ListenPort = <wireguard port> PrivateKey = <private key> Address = 192.168.99.1/24 PostUp = iptables -A FORWARD -i %i -j ACCEPT; \ iptables -t nat -A POSTROUTING -o ens5 -j MASQUERADE PostDown = iptables -D FORWARD -i %i -j ACCEPT; \ iptables -t nat -D POSTROUTING -o ens5 -j MASQUERADE [Peer] PublicKey = <public key of client (NUC)> AllowedIPs = 192.168.99.2/32
wireguard는 wg-quick과 사용하면 configuration 파일을 사용해서 간단하게 구성할 수 있습니다. 위는 서버를 구성하는 configuration 파일입니다.
wireguard를 구성하기 위해서는 public key와 private key를 발급해서 서로를 매칭 시켜야 합니다. public key와 private key는 wg genkey | tee private.key | wg pubkey > public.key 명령어로 한번에 생성할 수 있습니다.
위 파일을 /etc/wireguard/wg0.conf 에 위치하도록 하고 sudo wg-quick up wg0 명령어를 실행해서 설정할 수 있습니다. 재부팅시에는 자동으로 시작 되지 않을 수 있는데, sudo systemctl enable --now wg-quick@wg0 명령을 통해서 자동으로 시작 되게 할 수 있습니다.
또한 wireguard는 udp 패킷을 사용하기 때문에 포트를 udp로 열어야 합니다. 이때 ListenPort 옵션을 사용해서 기본 포트(51820)를 변경할 수 있습니다.
resource "aws_lightsail_instance_public_ports" "self" { instance_name = aws_lightsail_instance.self.name // ... port_info { from_port = 51820 to_port = 51821 cidrs = ["0.0.0.0/0"] protocol = "udp" } // ... }

클라이언트 구성

[Interface] Address = 192.168.99.2/24 PrivateKey = <private key> PostUp = ping -c 5 192.168.99.1 [Peer] PublicKey = <server public key> Endpoint = <server ip>:<wireguard port> AllowedIPs = 192.168.99.1/32 PersistentKeepalive = 10
클라이언트 구성에서는 조금의 트릭이 숨겨져 있습니다.
우선 클라이언트 노드들은 NAT 뒤에 숨어있기 때문에 서버측에서 바로 접근이 불가능하고, 클라이언트 쪽에서 연결을 맺어야만 접근이 가능합니다. 때문에 클라이언트에서 서버로 연결을 확인하기 위해서 초반에 5번의 ping을 보내고, 연결을 계속 유지하기 위해서 PersistentKeepalive 옵션을 10초로 주고 있습니다.
그 이유는 wireguard 자체가 가벼운 인터페이스를 추구하기 때문에 주고 받는 데이터가 없는 경우에는 따로 heartbeat와 같은 패킷을 주고 받지 않기 때문입니다. 때문에 아무런 통신이 없을 때에 클라이언트에서 서버로는 새로운 연결을 맺을 수 있지만, 서버에서 클라이언트로는 연결을 맺지 못하게 됩니다.

클러스터 구성하기

Server Node (Control Plane) 설정

일반적인 NIC가 아닌 wireguard를 통해서 통신을 하기 때문에 wireguard를 통해서 노드 간 통신을 해야한다고 알려주어야 합니다. 때문에 설정을 변경해서 옳게 노드를 찾을 수 있도록 합니다.
curl -sfL https://get.k3s.io | K3S_NODE_NAME=k3s-node-a \ sh -s - \ --node-ip 192.168.99.1 \ --node-external-ip=xx.xx.xxx.xx \ --flannel-iface=wg0
위와 같이 node-ipflannel-iface를 지정하면 됩니다.

Agent Node 설정

curl -sfL https://get.k3s.io | K3S_URL=https://192.168.99.1:6443 \ sh -s - \ --node-ip 192.168.99.2 \ --flannel-iface=wg0
agent에서도 server와 비슷하게 flannel-iface 를 지정해서 wireguard를 통해서 통신이 이루어지도록 합니다. 다만 server node의 /var/lib/rancher/k3s/server/node-token 에서 얻는 값을 K3S_TOKEN 환경변수로 지정하여야 합니다.

nodeAffinity/nodeSelector 설정

만일 PV를 사용하는 등의 이유로 특정 노드에서만 돌아가야 하는 경우에는 node affinity를 설정할 수 있습니다.
편의상 nodeSelector를 사용하고 있으며, 각 노드에 label을 붙인 후에 사용할 수 있습니다.
kubectl label nodes <nodeName> key=value
apiVersion: apps/v1 kind: Deployment spec: # ... template: # ... spec: nodeSelector: key: value

DNS 설정

coredns가 어떤 노드에 뜨는지에 따라서 dns resolve 형태가 달라질 수 있습니다.
이를 해결하기 위해서는 coredns의 설정을 덮어쓸 수 있습니다.
apiVersion: v1 kind: ConfigMap metadata: name: coredns-custom namespace: kube-system data: global.override: | forward . 8.8.8.8 8.8.4.4

IP Preservation

이런식으로 구성을 하는 경우에 Client의 Real IP가 제대로 보존 되지 않는 문제가 있었습니다.
이는 k3s가 사용하고 있는 svclb(klipper lb)가 요청을 전달하면서 SNAT를 하게 되는데, 이때 IP가 변조 되기 때문에 L7 계층에서는 바뀐 IP를 알 수 없기 때문입니다. 이를 해결하기 위해서는 PROXY 프로토콜을 쓸 수 있는 metal LB를 사용하는 등의 방식이 있지만, 어차피 external traffic은 control plane으로만 들어오기 때문에 꼭 LB를 사용할 필요가 없습니다. 따라서 svclb를 제거하고 traefik pod를 host network로 물려서 traefik이 요청을 직접 받도록 수정합니다.
/var/lib/rancher/k3s/server/manifests/traefik-config.yaml 를 수정해서 traefik을 설정합니다.
apiVersion: helm.cattle.io/v1 kind: HelmChartConfig metadata: name: traefik namespace: kube-system spec: valuesContent: |- hostNetwork: true nodeSelector: node-role.kubernetes.io/control-plane: "true" service: enabled: false updateStrategy: rollingUpdate: maxUnavailable: 1 maxSurge: 0 ports: metrics: port: 9111 web: port: 80 hostPort: 80 websecure: port: 443 hostPort: 443 securityContext: capabilities: drop: [ALL] add: [NET_BIND_SERVICE] podSecurityContext: runAsNonRoot: false runAsUser: 0 runAsGroup: 0
구체적인 내용은 아래와 같습니다.
  1. hostNetwork: true : traefik pod가 host network를 사용하게 해서 직접 http, https 요청을 받도록 합니다.
  1. nodeSelector : 기본적으로는 traefik은 deployment입니다. daemonset으로 바꿔서 모든 노드에 traefik이 다 뜨게 할 수도 있지만, 어차피 external traffic은 control plane으로만 들어오기 때문에 nodeSelector를 사용해서 control plane으로만 뜨도록 합니다.
  1. service.enabled: false : svclb를 비활성화 합니다.
  1. updateStrategy : traefik이 host network를 물고 있기 때문에 포트를 한번 놓아야지만 새로운 pod가 뜰 수 있습니다. 기존의 pod가 없어지고서 새로운 pod가 뜨게끔하는 설정입니다.
  1. ports.metrics.port: 9111 : prometheus의 node exporter가 9100 포트를 기본으로 사용하고 있기 때문에 traefik의 prometheus 포트를 9111로 수정했습니다.
  1. ports.web/websecure : 기본 포트가 8000, 8443이기 때문에 80, 443으로 수정합니다.
  1. securityContext/podSecurityContext : 1024 이하의 포트를 사용해서 열기 때문에 이 포트를 물기 위해서 추가적인 권한이 필요합니다
notion image
이전에는 X-Forwarded-For에 svclb의 Pod IP가 기록 되었지만, 이 작업 이후에 client IP가 정상적으로 보이는 것을 확인할 수 있습니다

인포팀에서 함께 일하고 싶다면?

지원 바로가기